Skip to content

Vanilla validator compounding staking strategy#2905

Open
naddison36 wants to merge 32 commits into
nicka/initial-deposit-32from
nicka/vanilla-staking
Open

Vanilla validator compounding staking strategy#2905
naddison36 wants to merge 32 commits into
nicka/initial-deposit-32from
nicka/vanilla-staking

Conversation

@naddison36

@naddison36 naddison36 commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR splits the compounding staking strategy into a deployable vanilla strategy and an SSV-specific extension, then wires the deployment flow for migrating away from the old compounding SSV strategy.

  • Refactors shared compounding validator logic into CompoundingStakingStrategy
  • Keeps SSV-specific behavior in CompoundingStakingSSVStrategy
  • Adds CompoundingStakingStrategyProxy for vanilla compounding validators
  • Adds CompoundingStakingStrategyView
  • Updates ConsolidationController to target the new vanilla compounding strategy
  • Allows old compounding SSV validators to be removed via ConsolidationController
  • Adds withdrawSsvClusterEth support to SSV strategies so empty SSV cluster ETH can be recovered
  • Splits old compounding SSV strategy cleanup into a follow-up governance proposal
  • Allows resetFirstDeposit to be called by either Governor or Strategist

Details

CompoundingStakingStrategy now contains the shared compounding validator logic:

  • strategy initialization
  • WETH/ETH deposit and withdrawal behavior
  • validator staking, withdrawal, proof verification, balance verification, and accounting
  • pause/config/admin functions
  • unsupported pToken/reward hooks

CompoundingStakingSSVStrategy now only contains SSV-specific behavior:

  • registerSsvValidator
  • removeSsvValidator
  • withdrawSsvClusterEth
  • SSV validator events and errors
  • SSV first-deposit admission override requiring prior REGISTERED, VERIFIED, or ACTIVE state

The vanilla strategy allows the first deposit to an unregistered validator:

  • NON_REGISTERED -> STAKED
  • follow-up deposits still require VERIFIED or ACTIVE
  • only one pending first deposit is allowed at a time

The initial validator deposit amount can be configured up to 2048 ETH, and first deposits are allowed up to the stored initialDepositAmountWei.

CompoundingStakingSSVStrategy.registerSsvValidator() requires validators to be in NON_REGISTERED state before registration, preventing already-used or REMOVED pubkeys from being registered again.

First Deposit Reset

resetFirstDeposit can now be called by either:

  • Governor
  • Vault Strategist

This lets the Strategist clear the first-deposit guard after the pending first deposit has been reviewed/handled, while still rejecting regular users.

SSV Cluster ETH Withdrawal

withdrawSsvClusterEth was added to both:

  • NativeStakingSSVStrategy
  • CompoundingStakingSSVStrategy

The function is governor-only and withdraws ETH from an empty SSV cluster. Any ETH held by the strategy is wrapped to WETH and transferred back to the OETH Vault.

SSV cluster helpers now normalize migrated ETH clusters so ethBalance is treated as the cluster balance.

Consolidation

ConsolidationController now targets the new CompoundingStakingStrategy and supports:

  • nativeStakingStrategy2 as the source strategy
  • the old CompoundingStakingSSVStrategy for post-upgrade validator removal

It forwards old native staking operations through to nativeStakingStrategy2, including:

  • registerSsvValidators
  • stakeEth
  • doAccounting
  • exitSsvValidator
  • removeSsvValidator

It also forwards target strategy operations needed during consolidation:

  • snapBalances
  • verifyBalances
  • validatorWithdrawal
  • compounding stakeEth

Deploy Scripts

199_deploy_compounding_staking_strategy.js:

  • deploys new CompoundingStakingSSVStrategy implementation
  • deploys new NativeStakingSSVStrategy implementation
  • deploys CompoundingStakingStrategyProxy
  • deploys CompoundingStakingStrategy
  • initializes with a 2030 ETH initial validator deposit amount
  • reuses the existing deployed BeaconProofs
  • deploys CompoundingStakingStrategyView
  • deploys a new ConsolidationController

Governance proposal actions:

  • upgrade the old CompoundingStakingSSVStrategyProxy
  • set the old compounding SSV strategy registrator to the new controller
  • upgrade NativeStakingSSVStrategy2Proxy
  • approve the new compounding strategy on the OETH Vault
  • set the new compounding strategy as the OETH Vault default strategy
  • set the new compounding strategy registrator to the new controller
  • set nativeStakingStrategy2 registrator to the new controller

200_remove_old_compounding_ssv_strategy.js:

  • requires the old compounding SSV cluster to have zero validators
  • withdraws remaining ETH from the old compounding SSV cluster
  • removes the old compounding SSV strategy from the OETH Vault

On fork tests, script 200 simulates removing the 10 old compounding SSV validators so the cleanup proposal can be tested end-to-end.

Operational Flow

  1. Execute governance proposal 199.
  2. Remove the 10 old compounding SSV validators through the controller:
npx hardhat removeValidator \
  --network mainnet \
  --type new \
  --consol true \
  --operatorids 2070,2071,2072,2073 \
  --pubkey <PUBKEY>
  1. Confirm the old compounding SSV cluster has validatorCount == 0.
  2. Execute governance proposal 200 to withdraw remaining cluster ETH and remove CompoundingStakingSSVStrategyProxy from the OETH Vault.

Testing

  • Added focused vanilla compounding staking unit tests
  • Updated SSV compounding unit tests for the split strategy behavior
  • Updated beacon proof fork test validator fixtures:
    • active validator: 1938267
    • exited validator: 1998612

Dependencies

This change is dependent on:

Code Change Checklist

To be completed before internal review begins:

  • The contract code is complete
  • Executable deployment file
  • Fork tests that test after the deployment file runs
  • Unit tests *if needed
  • The owner has done a full checklist review of the code + tests

Internal review:

  • Two approvals by internal reviewers

Refactored CompoundingStakingSSVStrategy to inherit CompoundingStakingStrategy.
Removed ConsolidationController.
Removed migrateClusterToETH.
@naddison36 naddison36 changed the title New CompoundingStakingStrategy without SSV functions. Vanilla compounding staking strategy and isolate SSV extension May 28, 2026
@naddison36 naddison36 changed the title Vanilla compounding staking strategy and isolate SSV extension Vanilla compounding staking strategy May 28, 2026
@codecov

codecov Bot commented May 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.22981% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 45.06%. Comparing base (9c90a5d) to head (31b4bd7).
⚠️ Report is 18 commits behind head on nicka/initial-deposit-32.

Files with missing lines Patch % Lines
...rategies/NativeStaking/ConsolidationController.sol 28.57% 9 Missing and 1 partial ⚠️
...ategies/NativeStaking/NativeStakingSSVStrategy.sol 16.66% 10 Missing ⚠️
...egies/NativeStaking/CompoundingStakingStrategy.sol 93.06% 7 Missing ⚠️
Additional details and impacted files
@@                     Coverage Diff                      @@
##           nicka/initial-deposit-32    #2905      +/-   ##
============================================================
- Coverage                     50.83%   45.06%   -5.78%     
============================================================
  Files                           110      110              
  Lines                          4873     4920      +47     
  Branches                       1353     1362       +9     
============================================================
- Hits                           2477     2217     -260     
- Misses                         2392     2699     +307     
  Partials                          4        4              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@naddison36 naddison36 changed the title Vanilla compounding staking strategy Vanilla validator compounding staking strategy May 29, 2026

@sparrowDom sparrowDom left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is another approach to vanilla staking where the risk of not being able to fully verify the deposit on-chain is transferred from trusting the node operator's honesty to trusting our strategist's honesty.

{
ValidatorState currentState = validator[pubKeyHash].state;
require(
currentState == ValidatorState.NON_REGISTERED ||

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per this comment we could allow for currentState == ValidatorState.STAKED to also admit a stake. This way we can:

  • do an initial deposit of 1 ETH
  • verify on beaconChain that it has been deposited
  • reset the first deposit (ideally via a strategist action)
  • deposit remaining 2029 ETH

We can not get front-run this way by the node operator. Extra risk is carried by the strategist though. I guess we are to decide who we can trust more.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the problem with doing a 1 ETH deposit first is it currently takes 55 days for the deposit to be processed on the beacon chain. That's needed before the validator and deposit can be verified. It then takes another 55 days for the second 2029 ETH deposit to be processed. So that's 110 days if no yield.
While we have a large amount of ETH to deposit, we are taking on the risk that a large validator deposit could be front-run by the node operator.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the point I am trying to make is that we can do 1ETH & 2029ETH deposits both in 1 day. The verification that we aren't front ran can be done off-chain without waiting for the deposit to be processed by the beacon chain.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice, but the staking contract would have to be changed to support this. Once the first initial deposit of 1 ETH is done, the validator status changes to STAKED. This prevents the second deposit from being made on the same day.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it would need to change (as proposed in the initial comment).

@sparrowDom

Copy link
Copy Markdown
Member

Claude's review

🔴 Blocker — 1. New implementation exceeds the EIP-170 size limit; migration 196 will fail to deploy on mainnet

CompoundingStakingSSVStrategy deployed (runtime) bytecode at HEAD = 25,077 bytes vs the 24,576-byte EIP-170 limit → over by 501 bytes, using
the repo's committed compiler settings (solc 0.8.28, optimizer.enabled = true, default runs, no overrides in hardhat.config.js). The
currently-deployed mainnet impl is 24,223 bytes.

Tests/compile pass locally only because allowUnlimitedContractSize: true is set on the hardhat network — but mainnet enforces EIP-170 at deploy
time
, so deployWithConfirmation("CompoundingStakingSSVStrategy", …) inside migration 196 would revert on mainnet.

Worth chasing: the committed config produces 25,077 bytes locally, yet the deployed mainnet artifact is only 24,223 bytes (854 smaller) — implying the
production pipeline historically used different/more-aggressive optimizer settings than what's committed, or the contract has grown since.

Action: Before queuing 196, compile under the exact production profile and confirm runtime bytecode < 24,576 (CONTRACT_SIZE=true pnpm hardhat compile). If over, raise optimizer.runs / add a per-contract override / viaIR, or move logic into a library.


🟠 High — 2. Off-chain stakeValidator skips BLS signature verification for first deposits below the cap

The contract treats any deposit to a REGISTERED validator as the validator-creating deposit (amount in [1 ETH, cap]). The beacon chain only checks
the BLS signature on that creating deposit; top-ups are not signature-checked. But the task gates verification on exact equality:

const amountWei = parseUnits(amount.toString(), 18);
const initialDepositAmountWei = await strategy.initialDepositAmountWei();
if (amountWei.eq(initialDepositAmountWei)) { /* verify real BLS sig */ }
else { /* substitute dummy 0x…0001 signature */ }

A first deposit of any amount below the cap (now a supported path, exercised by the new "Should stake less than the initial deposit amount" test)
takes the else branch and submits a dummy signature for a validator-creating deposit → the validator is never created and the ETH is stuck/lost.

Concretely reachable via the P2P/uuid flow: utils/p2pValidatorCompound.js hardcodes INITIAL_DEPOSIT_SIZE = "32000000000" (32 ETH), and the
--uuid path overwrites amount with it (tasks/validatorCompound.js:165). Migration 196 sets the cap to 32.25 ETH. 32 ≠ 32.25 → the task strips
the real P2P signature and submits a dummy one for the activating deposit.

Action: Detect the first/creating deposit by on-chain validator state (REGISTERED), not amount equality, and verify the real BLS signature whenever
state is REGISTERED regardless of amount. Align INITIAL_DEPOSIT_SIZE with the on-chain cap (or drive it from initialDepositAmountWei). Add an
integration test of the uuid path at the raised cap.


🟡 Medium — 3. Reusable upgradeCompoundingStakingSSVStrategy helper is a bare upgrade → initialDepositAmountWei == 0 DoS

The shared helper (deployActions.js:209–243, used by deploy/hoodi/003,006,007,009,010) does only upgradeTo(...) with no setInitialDepositAmount. On
any already-initialized proxy upgraded through it, the new slot defaults to 0; since first deposits require both >= 1 ether and <= initialDepositAmountWei, those constraints become mutually unsatisfiable → every new-validator first deposit reverts "Invalid first deposit amount"
until a governor sets the cap. Mainnet is safe (196 sets it atomically), but this is a footgun for future/testnet upgrades.

Action: Have the helper set a sane default (e.g. 1 ETH) if initialDepositAmountWei == 0 post-upgrade, or assert it's non-zero.

🟡 Medium — 4. Test coverage gaps

  • No stakeEth-level test of the production config. The renamed "…above the initial deposit amount" test never calls setInitialDepositAmount, so it
    only checks the cap against the default 1 ETH — same as the old == 1 ETH test. Add a test that raises the cap to 32.25 ETH and asserts a 32.25 ETH
    first deposit succeeds / >32.25 reverts.
  • No fork test for migration 196 (the PR's own "Fork tests that test after the deployment file runs" box is unchecked). Should assert
    initialDepositAmountWei() == 32.25 ETH post-proposal and that adjacent storage survives the gap shrink.
  • The off-chain sub-cap-signature path (Local Compound #2) has no test.

🟢 Low / Info / Nit

  • Stale storage-layout snapshot: storageLayout/mainnet/CompoundingStakingSSVStrategy.json still shows depositedWethAccountedFor → __gap[41] with no
    initialDepositAmountWei, and is not in the diff. Self-heals on mainnet deploy, but until then it can't catch a slot shift in review. Regenerate and
    commit.
  • PR description doesn't quantify the security trade-off: raising the cap moves worst-case per-incident front-run exposure 1 → 32.25 ETH (~32×). Worth
    one sentence + the mitigation (single-pending firstDeposit guard + resetFirstDeposit).
  • 32× per-incident exposure is an intentional, time-limited tradeoff; aggregate exposure stays bounded to one validator, front-run funds are correctly
    non-recoverable and accounted (verifyValidator:651–680). Ensure monitoring + resetFirstDeposit/pause runbooks are ready.
  • PlantUML diagram now says "1 ETH" — consistent with the default but doesn't depict the 32.25 ETH single-deposit window. Intentional per commit
    bb69b1e7a; consider a footnote.
  • ValidatorState.ACTIVE comment says "at least 32.25 ETH" but verifyBalances uses strict > (:1122) — should read "more than 32.25 ETH".
    Pre-existing; only the number changed here.

@naddison36

Copy link
Copy Markdown
Collaborator Author

Thanks for your review @sparrowDom

  1. I've got the contracts back under size. I've had to use more custom errors. These are not used by external users so will be ok.
  2. I've pushed a fix to the stakeValidator Hardhat task
  3. This upgrade helper is not used by the deploy script but I've updated it anyway.
  4. It's hard to fork test the consolidation to the new compounding staking contract when the contract or validators do not yet exist. We only have three more consolidations from the old NativeStakingStrategy. We could create unit tests with mocked beacon chain behaviour, but the beacon chain behaviour is key.

naddison36 and others added 6 commits June 9, 2026 19:06
…2914)

* All removeSsvValidator to be called for the Compounding Staking SSV Strategy on the ConsolidationController

* Split withdrawSsvClusterEth and removeStrategy into separate gov prop so the 10 validators can be removed post upgrade
* All removeSsvValidator to be called for the Compounding Staking SSV Strategy on the ConsolidationController

* Split withdrawSsvClusterEth and removeStrategy into separate gov prop so the 10 validators can be removed post upgrade

* Added back NON_REGISTERED check on registerSsvValidator
…use rebases) (#2911)

* migrate contracts to Talso signer

* fix fork tests

* add the migration for the OGN rewards module

* refactor deployment

* add some comments

* add base migration files

* add remaining migrations for base and one for sonic

* add hyperevm migration

* remove the old relayer roles
@sparrowDom

Copy link
Copy Markdown
Member

🟠 N-1 (residual H2) — stakeValidator --consol true submits a dummy BLS signature for a creating deposit

contracts/tasks/validatorCompound.js:174-216 (logic from dd4a4f98 / #2914, both post-review).

The task hardcodes strategy = CompoundingStakingSSVStrategyProxy and computes isCreatingDeposit = (strategy.validator(pubKeyHash).state == REGISTERED). But when --consol true, contract.stakeEth(...) routes through ConsolidationController.stakeEth (ConsolidationController.sol:469-479) to targetStrategy (the new vanilla CompoundingStakingStrategy), whose creating-deposit state is NON_REGISTERED (0), not REGISTERED (1). So for a real creating deposit on the target strategy the check is false → the task substitutes the dummy 0x…0001 signature → the validator is never created and the ETH is parked in a non-activating pending deposit (recoverable only via the withdrawal credentials).

--consol true is the only task path that can stake the new target strategy (the SSV proxy is hardcoded for the read), so this is the path that matters for creating target validators.

Fix: when consol, source the validator state (and the creating-deposit decision) from the target strategy — the controller already exposes it (ConsolidationController.sol:93 reads targetStrategy.validator(...)). Verify the real BLS signature whenever the target validator is in its creating state, regardless of amount. Add a test that drives the consol creating-deposit path.

🔵 N-2 (Low) — 197 removeStrategy relies on an unenforced, unsimulated lastVerifiedEthBalance ≈ 0

contracts/deploy/mainnet/197_remove_old_compounding_ssv_strategy.js (new file, #2914).

197's second action calls Vault.removeStrategy(oldCompoundingSSVStrategyProxy), which runs withdrawAll() then requires checkBalance(WETH) < maxDustBalance (1e13). checkBalance = lastVerifiedEthBalance + WETH.balanceOf(strategy). withdrawAll drains ETH/WETH but doesn't independently zero lastVerifiedEthBalance; if exited-validator ETH hasn't yet landed and been swept at execution time, the action reverts and blocks the whole proposal. 197 only guards on SSV validatorCount == 0. On fork, validators are forced EXITED via raw storage writes without setting lastVerifiedEthBalance, so the real precondition is never exercised.

Fix: before executing 197 on mainnet, assert off-chain checkBalance(WETH) < 1e13 and run verifyBalances/withdrawAll so lastVerifiedEthBalance is reduced first. Optionally add a fork test driving exit→verify→withdrawAll→removeStrategy.

⚪ N-3 (Info) — overloaded NotRegisteredOrVerified error gives a misleading revert on the register path

contracts/contracts/strategies/NativeStaking/CompoundingStakingSSVStrategy.sol:71-72 (re-added by #2915).

The re-added guard if (validator[pubKeyHash].state != NON_REGISTERED) revert NotRegisteredOrVerified(); reverts because the validator IS already registered, yet the error name asserts the opposite. The same error is correctly used in _admitStake. Off-chain consumers can't distinguish "already registered" from "not yet registered/verified." Guard logic is correct (tests confirm the revert).

Fix (optional): add a distinct error AlreadyRegistered(); for the register path.


4. Pre-existing issue surfaced now (not introduced since review)

🟡 M-1 — Legacy NativeStakingSSVStrategy.withdrawSsvClusterEth sweeps the entire ETH balance, ignoring consensusRewards

contracts/contracts/strategies/NativeStaking/NativeStakingSSVStrategy.sol:131-143. Present at the 2026-06-02 review (file is byte-identical REVIEW..HEAD; introduced 06-01 by cf8461db). Not new — a finding missed the first time.

ISSVNetwork(SSV_NETWORK).withdraw(operatorIds, amount, cluster);
uint256 ethBalance = address(this).balance;   // entire balance, not `amount`
if (ethBalance > 0) {
    IWETH9(WETH).deposit{ value: ethBalance }();
    IERC20(WETH).safeTransfer(vaultAddress, ethBalance);
    emit Withdrawal(WETH, address(0), ethBalance);
}

On the legacy strategy address(this).balance is not only cluster ETH: it can include accounted consensusRewards and un-accounted exit/withdrawal ETH. The sweep never decrements consensusRewards, so a later doAccounting() can see balance < consensusRewards_failAccountingpause (strategist-recoverable via manuallyFixAccounting). No fund loss (ETH reaches the legitimate vault), but it mistimes yield (bypasses the Dripper) and can overstate checkBalance. The sibling CompoundingStakingSSVStrategy.withdrawSsvClusterEth routes through _withdraw/_convertEthToWeth and stays consistent.

Reachability: onlyGovernor; 196 upgrades NativeStakingSSVStrategy2Proxy to the impl exposing it; no included script calls it on the legacy strategy — reachable only via a future governance proposal while the strategy holds reward/withdrawal ETH.

Fix: restrict the sweep to the amount withdrawn (snapshot before/after) and route through the strategy's normal accounting path, or require consensusRewards == 0 / run doAccounting first. Mirror the compounding sibling.


naddison36 and others added 3 commits June 10, 2026 11:26
* Migrate vault & cross-chain operators to the new Talos signer (+ unpause rebases) (#2911)

* migrate contracts to Talso signer

* fix fork tests

* add the migration for the OGN rewards module

* refactor deployment

* add some comments

* add base migration files

* add remaining migrations for base and one for sonic

* add hyperevm migration

* remove the old relayer roles

* Bump deploy numbers

* Add prop id to deploy 196 script

* Fix Hardhat task that deposits to compounding validators

* Hardhat task support for different compounding staking strategies

---------

Co-authored-by: Domen Grabec <grabec@gmail.com>
* Migrate vault & cross-chain operators to the new Talos signer (+ unpause rebases) (#2911)

* migrate contracts to Talso signer

* fix fork tests

* add the migration for the OGN rewards module

* refactor deployment

* add some comments

* add base migration files

* add remaining migrations for base and one for sonic

* add hyperevm migration

* remove the old relayer roles

* Bump deploy numbers

* Add prop id to deploy 196 script

* Clear error when SSV validator has already been registered

---------

Co-authored-by: Domen Grabec <grabec@gmail.com>
@naddison36

Copy link
Copy Markdown
Collaborator Author

Thanks @sparrowDom

N-1 (residual H2) — stakeValidator --consol true submits a dummy BLS signature for a creating deposit
Has been fixed in PR #2918
I've also added a new ssv option that defaults to false to all the compounding staking strategy Hardhat tasks.

N-2 (Low) — 197 removeStrategy relies on an unenforced, unsimulated lastVerifiedEthBalance ≈ 0
This won't be a problem as all the compounding validators have been exited and swept.

N-3 (Info) — overloaded NotRegisteredOrVerified error gives a misleading revert on the register path
Has been fixed in PR #2919

M-1 — Legacy NativeStakingSSVStrategy.withdrawSsvClusterEth sweeps the entire ETH balance, ignoring consensusRewards
FIxed in PR #2920

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants